TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { requireUser } from '@/lib/auth/session';5import { getCollectionDetail, listCollections } from '@/lib/account/queries';6import { getDisplay } from '@/lib/account/display';7import { deleteCollectionAction, toggleCollectionPublicAction } from '@/lib/account/actions';8import { PageHeader, btnSecondary, btnDanger, btnPrimary } from '@/components/account/page-header';9import { SummaryStats, AllocationCards, ValueSourceBadge } from '@/components/account/portfolio-widgets';10import { LineChart } from '@/components/account/charts';11import { Card, CardHeader, Delta, EmptyState, Table, th, td, tdNum, Badge } from '@/components/ui/primitives';12import { fmtDate, fmtPct, confidenceLabel } from '@/lib/format';13import { EditCollectionForm } from '../create-form';14import { AddItemForm } from './add-item-form';1516export const metadata: Metadata = { title: 'Collection', robots: { index: false } };1718export default async function CollectionPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<Record<string, string | undefined>> }) {19 const { id } = await params;20 const sp = await searchParams;21 const u = await requireUser(`/collections/${id}`);22 const detail = await getCollectionDetail(u.id, id);23 if (!detail) notFound();24 const d = await getDisplay();25 const { collection: c, summary: s, items, history } = detail;26 const others = (await listCollections(u.id)).filter((x) => x.collection.id !== id);27 const sort = sp.sort ?? 'value';28 const rows = [...s.items].sort((a, b) => {29 if (sort === 'gain') return (b.gainPct ?? -Infinity) - (a.gainPct ?? -Infinity);30 if (sort === 'name') return a.title.localeCompare(b.title);31 if (sort === 'recent') return (items.find((i) => i.id === b.id)?.createdAt.getTime() ?? 0) - (items.find((i) => i.id === a.id)?.createdAt.getTime() ?? 0);32 return (b.valueUsd ?? -1) - (a.valueUsd ?? -1);33 });34 const publicUrl = c.isPublic && u.handle && c.publicSlug ? `/u/${u.handle}/${c.publicSlug}` : null;3536 return (37 <>38 <nav className="mb-2 text-xs text-muted">39 <Link href="/collections" className="hover:text-fg">40 Collections41 </Link>{' '}42 / <span className="text-fg">{c.name}</span>43 </nav>44 <PageHeader45 title={46 <span className="flex items-center gap-2">47 {c.color ? <span className="h-3 w-3 rounded-full" style={{ background: c.color }} /> : null}48 {c.name}49 {c.isPublic ? <Badge tone="index">public</Badge> : <Badge tone="neutral">private</Badge>}50 </span>51 }52 description={c.description ?? `${s.itemCount} item${s.itemCount === 1 ? '' : 's'} · created ${fmtDate(c.createdAt)}`}53 actions={54 <>55 <a href="#add" className={btnPrimary}>56 Add item57 </a>58 <Link href={`/collections/${id}/import`} className={btnSecondary}>59 Import CSV60 </Link>61 <Link href={`/collections/${id}/insurance`} className={btnSecondary}>62 Insurance schedule63 </Link>64 <a href={`/api/account/collections/${id}/export?format=csv`} className={btnSecondary}>65 CSV66 </a>67 <a href={`/api/account/collections/${id}/export?format=json`} className={btnSecondary}>68 JSON69 </a>70 </>71 }72 />73 <SummaryStats s={s} d={d} className="mb-5" />74 {s.valuedCount > 0 ? (75 <div className="mb-5 space-y-4">76 <Card>77 <CardHeader title="Value history" subtitle={history.length ? `${history.length} daily snapshots (value vs cost basis)` : 'Snapshots are recorded daily by the RareIndex worker'} />78 <div className="p-4">79 <LineChart series={[{ name: 'Value', points: history.map((h) => ({ date: String(h.date), value: h.valueUsd * d.rate })) }, { name: 'Cost basis', points: history.map((h) => ({ date: String(h.date), value: h.costBasisUsd * d.rate })), color: 'var(--ri-fg-subtle)', dashed: true }]} height={180} formatY={(v) => d.money(v / d.rate, { compact: true })} />80 </div>81 </Card>82 <AllocationCards s={s} d={d} />83 </div>84 ) : null}8586 <Card className="mb-5">87 <CardHeader88 title="Items"89 subtitle={`${s.itemCount} item${s.itemCount === 1 ? '' : 's'} · ${s.unitCount} unit${s.unitCount === 1 ? '' : 's'}`}90 action={91 <div className="flex gap-1 text-xs">92 {[93 ['value', 'Value'],94 ['gain', 'Return'],95 ['recent', 'Recent'],96 ['name', 'Name'],97 ].map(([k, l]) => (98 <Link key={k} href={`?sort=${k}`} className={`rounded-sm px-2 py-1 ${sort === k ? 'bg-inset text-fg' : 'text-muted hover:text-fg'}`}>99 {l}100 </Link>101 ))}102 </div>103 }104 />105 {rows.length === 0 ? (106 <EmptyState title="This collection is empty" description="Add an item below by searching the RareIndex asset universe, or import a CSV." />107 ) : (108 <Table>109 <thead>110 <tr>111 <th className={th}>Item</th>112 <th className={th}>Variant</th>113 <th className={`${th} text-right`}>Qty</th>114 <th className={`${th} text-right`}>Cost</th>115 <th className={`${th} text-right`}>Value</th>116 <th className={`${th} text-right`}>Gain</th>117 <th className={`${th} text-right`}>30d</th>118 <th className={th}>Source</th>119 <th className={th}></th>120 </tr>121 </thead>122 <tbody>123 {rows.map((i) => {124 const src = items.find((x) => x.id === i.id)!;125 return (126 <tr key={i.id} className="hover:bg-sunken">127 <td className={td}>128 <div className="flex items-center gap-2.5">129 {src.photos[0] || src.heroImageUrl ? (130 // eslint-disable-next-line @next/next/no-img-element131 <img src={src.photos[0] ?? src.heroImageUrl ?? ''} alt="" className="h-9 w-9 rounded-sm object-cover" />132 ) : (133 <span className="h-9 w-9 rounded-sm bg-inset" />134 )}135 <div className="min-w-0 max-w-[280px]">136 <Link href={`/collections/${id}/items/${i.id}`} className="block truncate font-medium hover:underline">137 {i.title}138 </Link>139 <p className="truncate text-[11px] text-subtle">140 {i.categorySlug.replace(/_/g, ' ')}141 {i.acquiredAt ? ` · acquired ${i.acquiredAt}` : ''}142 {src.tags.length ? ` · ${src.tags.join(', ')}` : ''}143 </p>144 </div>145 </div>146 </td>147 <td className={`${td} text-xs`}>{src.variantLabel ?? (i.grader ? `${i.grader.toUpperCase()} ${i.grade ?? ''}` : src.condition ?? '—')}</td>148 <td className={tdNum}>{i.quantity}</td>149 <td className={tdNum}>{i.costUsd === null ? <span className="text-subtle">—</span> : d.money(i.costUsd)}</td>150 <td className={tdNum}>151 {i.valueUsd === null ? <span className="text-subtle" title="No market evidence yet">—</span> : <span title={i.confidence !== null ? `Confidence ${confidenceLabel(i.confidence)}` : undefined}>{d.money(i.valueUsd)}</span>}152 </td>153 <td className={tdNum}>154 <Delta value={i.gainPct} />155 </td>156 <td className={tdNum}>157 <span className="text-xs">{fmtPct(i.change30d)}</span>158 </td>159 <td className={td}>160 <ValueSourceBadge item={i} />161 </td>162 <td className={td}>163 <Link href={`/collections/${id}/items/${i.id}`} className="text-xs text-muted hover:text-fg">164 Edit165 </Link>166 </td>167 </tr>168 );169 })}170 </tbody>171 </Table>172 )}173 </Card>174175 <div id="add" className="mb-5"><Card>176 <CardHeader title="Add an item" subtitle="Search the RareIndex asset universe, then record what you paid. Photos and notes can be added after saving." />177 <div className="p-4">178 <AddItemForm collectionId={id} />179 </div>180 </Card></div>181182 <div className="grid gap-4 lg:grid-cols-2">183 <Card>184 <CardHeader title="Sharing" subtitle="Private by default. Public collections appear on your collector profile with values and photos — never purchase prices." />185 <div className="space-y-3 p-4 text-sm">186 <form action={toggleCollectionPublicAction} className="flex items-center gap-3">187 <input type="hidden" name="collectionId" value={id} />188 <label className="flex items-center gap-2">189 <input type="checkbox" name="public" defaultChecked={c.isPublic} className="h-4 w-4" />190 Make this collection public191 </label>192 <button className={btnSecondary}>Save</button>193 </form>194 {c.isPublic ? (195 publicUrl ? (196 <p className="text-xs text-muted">197 Public at{' '}198 <Link href={publicUrl} className="text-fg underline">199 {publicUrl}200 </Link>201 </p>202 ) : (203 <p className="text-xs text-alert">204 Claim a public handle in{' '}205 <Link href="/account/settings#handle" className="underline">206 profile settings207 </Link>{' '}208 to get a shareable URL.209 </p>210 )211 ) : null}212 </div>213 </Card>214 <Card>215 <CardHeader title="Settings" />216 <div className="space-y-4 p-4">217 <EditCollectionForm collection={{ id: c.id, name: c.name, description: c.description, kind: c.kind, budgetUsd: c.budgetUsd, color: c.color }} />218 <div className="flex items-center justify-between border-t border-border pt-4">219 <p className="text-xs text-muted">220 {others.length ? `Move items to another collection from each item page.` : 'Create another collection to move items between them.'}221 </p>222 <form action={deleteCollectionAction}>223 <input type="hidden" name="collectionId" value={id} />224 <button className={btnDanger}>Delete collection</button>225 </form>226 </div>227 </div>228 </Card>229 </div>230 </>231 );232}233